Skip to content

Retry user-initiated splices across restarts and disconnects - #930

Open
jkczyz wants to merge 6 commits into
lightningdevkit:mainfrom
jkczyz:2026-06-splicing-restart
Open

Retry user-initiated splices across restarts and disconnects#930
jkczyz wants to merge 6 commits into
lightningdevkit:mainfrom
jkczyz:2026-06-splicing-restart

Conversation

@jkczyz

@jkczyz jkczyz commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

LDK abandons an in-progress splice negotiation whenever the peer disconnects (which includes stopping the node) and only durably records a splice once it reaches signing. Between calling splice_in/splice_out/bump_channel_funding_fee and that point, a restart or an ill-timed disconnect silently drops the splice with no way to recover it.

This makes those splices durable and self-healing: the intent is persisted before the contribution is handed to LDK, and a startup reconciler plus a SpliceNegotiationFailed handler resubmit it (gated on LDK's NegotiationFailureReason::is_retriable) until the splice locks or is genuinely unrecoverable. As a result SpliceNegotiationFailed is now emitted only once a splice is given up on, rather than for every failed negotiation round.

Rather than adding a dedicated store, the intent lives in the existing PendingPaymentStore under a PaymentId generated at splice time — which becomes the splice's payment id, replacing the previous first-candidate-txid derivation. That required modeling the pending record as an enum, since a not-yet-broadcast splice has no funding transaction, and therefore no PaymentDetails, yet. The change is scoped to user-initiated splices; counterparty-initiated splices and V2 opens are untouched and keep the txid-derived id.

Restart resumption is covered by two integration tests (a splice-out and an RBF fee-bump), alongside unit coverage of the retry-decision matrix and a test that a promoted 0conf splice's payment record survives LDK's funding re-broadcasts; the existing splice/funding/on-chain suites continue to pass.

Based on #1049.

Generated with assistance from Claude Code (Claude Fable 5).

@ldk-reviews-bot

ldk-reviews-bot commented Jun 11, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @joostjager as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@jkczyz jkczyz self-assigned this Jun 11, 2026
@tnull tnull added this to the 0.8 milestone Jun 12, 2026
@jkczyz jkczyz mentioned this pull request Jun 12, 2026
@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch 2 times, most recently from 3ae2507 to 6098d0e Compare June 15, 2026 19:46

@joostjager joostjager left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description explains that LDK currently does not durably record a splice until signature exchange, but I don’t think it explains why ldk-node should therefore become the owner of that durability?

This seems similar to the functionality added in #882: common enough, and close enough to channel/protocol state, that it feels like it should live in LDK if we want the behavior to be durable. Persisting it in ldk-node means we now have another store tracking protocol state alongside ChannelManager/ChannelMonitor, plus reconciliation logic to infer whether LDK still has or no longer has the splice. That creates desync risk between persistence layers.

I can see ldk-node owning product policy around retries or how/when to surface failures, but the durable record of an accepted splice contribution or in-flight splice intent feels like it should be owned by LDK.

@tnull

tnull commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

This will now need a (likely rather considerable) rebase now that #888 and a few related PRs landed.

@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch from 6098d0e to c005623 Compare July 2, 2026 15:34
@jkczyz

jkczyz commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Re-wrote this to use the exiting PendingPaymentStore as discussed offline. See PR description for details. @tnull Not sure what you think about making PendingPaymentDetails an enum that transitions (see 29a93ef). But high-level feedback on that design would be appreciated.

@joostjager

Copy link
Copy Markdown
Contributor

I’d still be interested in the rationale for why this needs to live in ldk-node rather than ldk.

If ldk drops in-progress splice negotiation state on disconnect or restart before signing, doesn’t every splicing integration need to persist intent, reconcile on restart, retry when appropriate, and surface final failure?

@jkczyz

jkczyz commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

I’d still be interested in the rationale for why this needs to live in ldk-node rather than ldk.

If ldk drops in-progress splice negotiation state on disconnect or restart before signing, doesn’t every splicing integration need to persist intent, reconcile on restart, retry when appropriate, and surface final failure?

Hmm... yeah it seems we didn't fully resolve this a couple meetings ago. The conversation was mostly around whether to persist in LDK Node by payment ID or channel ID.

Currently, if we have reached quiescence but haven't exchanged signatures, LDK will opportunistically persist an Event::SpliceNegotiationFailed with NegotiationFailureReason::PeerDisconnected. This allows LDK Node to retry the splice when processing the event. However, if we haven't reached quiescence, no such event is persisted nor is the corresponding QuiescentAction::Splice, which is essentially the intent.

IIUC, even if we persisted an Event::SpliceNegotiationFailed for the QuiescentAction::Splice, a user may initiated the splice using ChannelManger::splice_channel / ChannelManager::funding_contributed, but we aren't guaranteed ChannelManager has been persisted before funding_contributed returns. Thus, if a restart happens after returning but before persistence, we've lost the intent.

cc: @TheBlueMatt @wpaulino

@TheBlueMatt

Copy link
Copy Markdown
Contributor

Right, currently we require the same downstream intent storage for outbound payments and splices (which are often outbound payments). One thing I've thought of is having those methods return a Future that completes when the channelmanager/monitors finish persistence and the intent is guaranteed to not be lost, which would then allow ldk-node to bubble that Future up to the caller and make the whole thing the caller's responsibility (which ultimately likely means the end user will be responsible for retrying if their app crashes when they were mid-payment). Its something we could explore for 0.4.

jkczyz and others added 6 commits August 13, 2026 09:40
Retrying a user-initiated splice across restarts requires persisting the
splice intent before handing it to LDK, which happens before negotiation
and therefore before any funding transaction exists. The pending-payment
record was built around an on-chain PaymentDetails carrying a txid, which
cannot represent a splice that has not been broadcast yet.

Reshape PendingPaymentDetails into an enum: a PendingSplice variant that
holds only the generated PaymentId and the splice intent, and a Tracked
variant that is the previous record plus an optional intent retained until
the splice locks. Add the SpliceIntent and SpliceKind types the intent
needs to resubmit or rebuild the contribution.

Wallet writes to the pending store go through DataStore::mutate, replacing
racy read-then-write pairs. They share one helper whose closure re-reads
the payment's status inside the critical section — only Pending payments
belong in the pending store, and a status read taken outside it can go
stale against graduation — and promotes a bare PendingSplice to a Tracked
record once a payment exists under its id: a plain payment-tracking merge
would silently no-op against the variant, leaving the splice invisible to
txid lookups.

This is groundwork; nothing constructs a PendingSplice yet. The classify,
retry, and wiring that use it follow in subsequent commits.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user-initiated splice will be keyed by a PaymentId generated at splice
time rather than derived from a candidate's txid, so its retry intent,
funding payment, and candidate history all share one record. Teach the
classifier to find a pre-broadcast splice intent by its channel and reuse
that id, promoting the intent record to a tracked funding payment while
preserving the intent until the splice locks. Splices we did not originate
(counterparty-initiated or V2 dual-funded opens) keep deriving the id from
the first candidate's txid via a fallback.

A splice under a generated id is no longer found by the txid-derived
lookup, so it leans on find_payment_by_txid's candidate probe to map its
txids back to the record. If the intent is already gone when
classification runs, the classifier probes those same lookups for a
record any candidate already created before minting a txid-derived id, so
a wallet sync that recorded the transaction first and a late
classification converge on one record.

The generic funding classification resolves an existing record the same
way before minting a txid-derived id: LDK re-broadcasts a
promoted-but-unconfirmed 0conf funding transaction through that path, and
the rebroadcast must merge into the record classification already created
rather than mint a duplicate.

Promotion of a pre-broadcast intent in persist_funding_payment is gated on
the payment still being Pending, read inside the pending store's critical
section like the rest of the write's decision: a payment that confirmed
through ANTI_REORG_DELAY before classification must not re-enter the
pending store, which graduation and rebroadcast assume holds only Pending
payments.

No splice intents are created yet; the splice entry points that persist
them follow.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LDK abandons an in-progress splice negotiation whenever the peer
disconnects -- which includes stopping the node -- and only durably records
a splice once its negotiation reaches signing. A splice dropped before then,
after splice_in, splice_out, or bump_channel_funding_fee returned Ok, is
therefore silently lost across a restart or an ill-timed disconnect.

Persist a splice intent before handing the contribution to LDK, keyed by a
PaymentId generated at splice time and reusing the channel's existing intent
record when one is present, so a splice and its fee bumps share one id and at
most one intent exists per channel. At startup a reconciler probes each intent
against LDK's live channel state and resubmits any LDK dropped -- including
those lost to a crash before LDK persisted anything -- surfacing
SpliceNegotiationFailed only when the channel is gone, a fee bump has nothing
left to replace, or the resubmission budget is exhausted. Resubmitting does
not require the peer to be connected: LDK holds the contribution and initiates
quiescence on reconnect.

Dropping an intent must not drop the payment tracking behind it. A crash
between classification's two store writes leaves the payment recorded while
the pending entry is still pre-broadcast, so the reconciler consults the
payment store as well as the entry itself, and clearing the intent promotes
such an entry to a tracked funding payment so the payment keeps graduating.
A payment no longer Pending already graduated and is not re-indexed.

Wallet sync or a restarted broadcast classification can see the splice
transaction while only the pre-broadcast intent records it -- the
counterparty broadcasts the transaction too, and a crash can leave the
intent as the only trace of the splice-time id -- but an intent record
carries no txids for the usual lookup to match. Teach both writers to
recognize such a transaction by the funding outpoint it spends and adopt
the splice-time id, so they converge on one record instead of minting a
txid-derived duplicate.

A payment-tracking merge (e.g. from wallet sync) must leave a live intent
untouched. The splice tests now locate a funding payment by its candidate
txid, since its id is generated rather than derived from the funding txid.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A user-initiated splice can fail mid-negotiation while the node is running
-- the peer disconnects, or the contribution goes stale behind a competing
negotiation -- and LDK reports each such round via SpliceNegotiationFailed.

Drive those events through the splice retrier: resubmit the same
contribution when the peer merely disconnected, rebuild a fresh one when it
went stale, and give up (surfacing the failure) only for a non-retriable
reason or once the resubmission budget is exhausted, using LDK's own
is_retriable classification. Clear a splice's intent once the channel locks
its new funding or the channel closes.

Event::SpliceNegotiationFailed is now emitted only when a splice is finally
abandoned, not for every failed negotiation round, since a recoverable
failure is retried transparently.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add integration coverage for resuming a dropped splice: splice_resumed_after_restart
initiates a splice-out while disconnected, restarts the node before anything is
negotiated, and asserts the reconciler resumes and completes the splice -- and that a
second restart does not resubmit the now-locked splice. splice_rbf_resumed_after_restart
does the same for a fee bump.

Also cover the id-agreement race: splice_payment_tracked_across_restart_before_lock
stops the node right after splice negotiation, lets the counterparty's broadcast
confirm while it is down, and asserts after restart that wallet sync and
classification -- landing in either order -- produce exactly one payment record,
keyed by the splice-time id rather than a txid-derived one, through to Succeeded.

Document on splice_in, splice_out, and bump_channel_funding_fee that the splice is
retried automatically across restarts until it completes or is given up on.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A splice on a 0conf channel locks before its funding transaction
confirms, so LDK promotes the new funding immediately and re-broadcasts
the still-unconfirmed transaction on every monitor-update completion,
re-typed as a generic funding transaction with wallet-view figures.
Exercise the full cycle end to end: the contributing side must keep a
single record with the splice-time id, interactive-funding
classification, and contribution-derived figures through the
re-broadcasts and on to graduation, and the non-contributing side must
not record anything.

Generated with assistance from Claude Code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jkczyz
jkczyz force-pushed the 2026-06-splicing-restart branch from cadfed5 to 1adcc98 Compare August 13, 2026 15:13
@jkczyz
jkczyz marked this pull request as ready for review August 13, 2026 15:14
@jkczyz
jkczyz requested review from joostjager and tnull August 13, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants